14장 여러 자원 엮어 쓰기 — 입문판

원서: API Design Patterns (Manning, JJ Geewax) 14장을 입문자용으로 다시 풀어 썼습니다. 1차 표준 출처: 이 장의 연관 리소스 패턴은 Google의 API 설계 가이드라인인 AIP-124(Resource association)에 기반합니다. 원전은 https://google.aip.dev/124 에서 볼 수 있어요.

AIP가 뭐예요? AIP는 API Improvement Proposals(API 개선 제안)의 줄임말이에요. Google이 자사 API를 설계할 때 쓰는 공개 규칙 모음이고, google.aip.dev 에 전부 공개돼 있어요. 일상 비유로, 회사마다 있는 "문서 작성 사규집" 같은 거예요. (모두가 같은 양식으로 쓰도록 정해둔 규칙집) 이 입문판이 풀어 쓴 내용이 어디서 왔는지 궁금하면, 원서와 함께 이 AIP 문서를 1차 출처로 보면 됩니다. 그중 연관 리소스를 다루는 항목이 AIP-124예요. 이 장을 다 읽은 뒤 원문과 대조하면 같은 이야기를 더 압축된 영어 규칙으로 만날 수 있어요.

이 장에서 딱 6가지만: 1. 다대다 관계가 뭐고, 왜 단순 참조로는 안 터지는지 2. 연관 리소스(관계를 따로 만든 자원)가 뭔지 3. 연관 리소스에 어떤 메서드를 붙이는지 (만들기·지우기·나열하기) 4. 같은 짝은 하나만, 양쪽 이름표는 못 바꾼다 (고유성·읽기 전용) 5. 자주 쓰는 조회를 줄여 쓰는 별칭 메서드 6. 가리키던 대상이 사라질 때 어떻게 처리하나 (참조 무결성)

이 장을 다 읽으면 이런 걸 할 수 있어요.

  • 일대다 관계와 다대다 관계를 구분한다.
  • 연관 리소스가 필요한 상황을 설명한다.
  • 연관 리소스의 표준 메서드와 별칭 메서드를 구분한다.
  • 고유성 제약과 읽기 전용 필드가 왜 있는지 설명한다.
  • 참조 무결성의 4가지 처리 방식을 구분한다.

1. 왜 이런 게 필요할까 — 단순 참조로 짜면 터진 적 있죠?

학생과 동아리를 연결하는 API를 만든다고 해봅시다.

처음엔 이렇게 짜요. 학생(User) 안에 "내가 속한 동아리 목록"을 적어두는 거죠.

User {
  id: "users/jimmy",
  name: "지미",
  groupIds: ["groups/축구부", "groups/밴드부"]   // 속한 동아리 목록
}

깔끔해 보이는데, 곧 이런 질문이 들어옵니다. "지미가 축구부에서 회장이야 부원이야?" "지미는 언제 가입했어?"

이 정보를 어디 적죠? User에 적자니 동아리마다 역할이 다르고, Group에 적자니 사람마다 역할이 다릅니다. 양쪽 다 어색해요.

또 다른 질문. "축구부에 속한 학생 전부 보여줘." 그러려면 모든 학생을 한 명씩 다 뒤져서 groupIds에 축구부가 있나 확인해야 해요. 학생이 100만 명이면? 전부 스캔. 느립니다.

이렇게 한 명이 여러 곳에, 그 여러 곳도 다시 여러 명을 갖는 관계, 그리고 그 관계 자체에 적어둘 정보가 있을 때 단순 참조는 터집니다.

그게 바로 다대다 관계예요.

다대다 관계: 한쪽이 여러 상대와 연결되고, 그 상대도 다시 여러 쪽과 연결되는 관계. 일상 비유로, 학생-동아리 같은 거예요. (학생 한 명이 동아리 여럿, 동아리 하나에 학생 여럿)

관계 메타데이터: 관계 그 자체에 붙는 추가 정보. 일상 비유로, "이 사람이 이 동아리에서 회장이고, 작년 3월에 들어왔다" 같은 꼬리표예요.

비유부터 표로 연결

비유 API/코드 위험·주의
학생 한 명이 동아리 여럿에, 동아리 하나에 학생 여럿 다대다 관계 단순 배열 참조로는 관계 정보를 못 적음
"회장이냐 부원이냐", "언제 가입했냐" 꼬리표 관계 메타데이터 User에도 Group에도 두기 어색함
"축구부 학생 전부 보여줘" 양방향 조회 배열 방식은 전체 스캔이라 느림

잘못된 예 vs 올바른 예

잘못된 예 — 관계 정보를 User 안에 욱여넣기

User {
  groupIds: ["groups/축구부"],
  roleInSoccer: "회장",        // 동아리 늘면 필드도 계속 늘어남
  roleInBand: "부원"
}

올바른 예 — 관계를 따로 빼서 자원으로 만들기 (다음 절에서 설명)

Membership {
  userId: "users/jimmy",
  groupId: "groups/축구부",
  role: "회장",
  joinedAt: "2024-03"
}

한 걸음 더 ▸ 데이터베이스를 아는 분께: 위 Membership은 DB의 join table(다대다를 저장하는 중간 표)과 똑같은 발상이에요. DB에서 하던 걸 API 자원으로 노출한 것뿐입니다. 몰라도 다음 내용 이해엔 지장 없어요.


2. 연관 리소스 — 관계를 아예 자원으로 만든다

앞에서 본 문제의 답은 간단해요. 관계 자체를 하나의 자원으로 만드는 것입니다.

이걸 연관 리소스라고 불러요.

연관 리소스: 두 자원 사이의 다대다 관계를 표현하려고 따로 만든 독립 자원. 일상 비유로, 결혼 증명서 같은 거예요. (신랑·신부 두 사람을 엮은 종이 한 장이 따로 존재)

학생-동아리 예에서는 이 종이의 이름이 Membership(멤버십)입니다.

Membership {
  id: "memberships/cm-1",
  userId: "users/jimmy",    // 누구와
  groupId: "groups/축구부",  // 어디를 엮었나
  role: "회장",             // 관계에 붙는 정보
  joinedAt: "2024-03-15"
}

이제 "만들기 = 가입", "지우기 = 탈퇴"가 됩니다. 가입하면 종이 한 장 발급, 탈퇴하면 종이 파기.

연관 리소스가 주는 3가지

  1. 관계를 만들고·확인하고·지울 수 있다 (가입/조회/탈퇴)
  2. 관계에 정보를 붙일 수 있다 (역할, 가입일, 만료일)
  3. 양쪽 모두에서 조회된다 ("이 학생의 동아리는?" + "이 동아리의 학생은?")

비유부터 표로 연결

비유 API/코드 위험·주의
결혼 증명서 한 장 Membership 자원 1개 어느 한쪽의 소유물이 아니라 독립 자원
종이 발급 = 가입 Create(POST) 같은 짝 종이는 하나만 (3절)
종이 파기 = 탈퇴 Delete(DELETE) 종이만 지움, 사람·동아리는 그대로

예시 2개

예시 1 — Slack 채널 멤버십

Alice가 #general에서는 admin,
Alice가 #engineering에서는 member.

같은 사람인데 채널마다 역할이 달라요.
그래서 역할은 "사람"이 아니라 "관계"에 붙어야 합니다.

ChannelMembership { userId: "Alice", channelId: "#general", role: "admin" }
ChannelMembership { userId: "Alice", channelId: "#engineering", role: "member" }

예시 2 — GitHub 저장소 협업자

RepositoryCollaboration {
  userId: "users/alice",
  repositoryId: "repos/my-repo",
  permission: "write",       // 권한 수준
  invitedAt: "2024-03-15"    // 초대 시점
}

한 걸음 더 ▸ 일대다라면 13장(교차 참조)이 더 단순해요. "책 한 권에 저자 한 명"처럼 한쪽만 여럿이면 그냥 authorId 하나 두면 끝. 연관 리소스는 양쪽 다 여럿일 때 꺼내는 카드예요.


3. 처음부터 끝까지 따라가기 — 온라인 강의 플랫폼

머리로만 보면 안 와닿으니, 강의 플랫폼(인프런 같은)을 한 번 만들어 봅시다.

요구사항부터.

- 학생은 여러 강의를 듣는다
- 강의에는 여러 학생이 있다
- 관계에 정보가 필요하다:
  · 수강 시작일 (enrolledAt)
  · 진도율 (progress, 0~100)
  · 수료 여부 (completed)
  · 수강권 만료일 (expireTime)

다대다 + 관계 정보. 연관 리소스 자리예요. 이름은 도메인 용어를 살려 CourseEnrollment(수강 등록)로 합니다.

먼저 안 되는 길을 밟아보기

길 1 — 학생 안에 courseIds 배열 (1절에서 본 그 방식)

Student { courseIds: ["courses/python-101"] }

문제: 진도율을 어디 적지?
강의마다 진도가 다른데 Student.progress 하나로는 표현 불가.
필드를 맵으로 늘리면 만료일·수료여부까지 맵 폭발.

이건 일대다 + 정보 없을 때만 맞는 길이에요. 여기선 부적합.

길 2 — 그냥 add/remove (학생을 강의에 "넣기/빼기")

POST /courses/python-101/students:add { studentId: "alice" }
→ 넣는 건 되는데, 진도율 75%를 어디 적지? 못 적음.

이건 다대다지만 정보 없을 때만 맞는 길. 여기서도 부적합.

add/remove(넣기/빼기)는 관계의 "있다/없다"만 다뤄요. 정보를 못 붙입니다. 자세한 건 다음 장 예고에서 한 줄 더.

되는 길 — 연관 리소스 설계

CourseEnrollment {
  id: "enrollments/enr-001",
  studentId: "students/alice",  // 읽기 전용 (어떤 학생)
  courseId: "courses/python-101", // 읽기 전용 (어떤 강의)
  enrolledAt: "...",            // 서버가 자동 기록
  progress: 0,                  // 진도율
  completed: false,             // 수료 여부
  expireTime: "..."             // 만료일
}

DB로 치면 이렇게 생긴 표예요.

CREATE TABLE enrollments (
    id TEXT PRIMARY KEY,
    student_id TEXT NOT NULL REFERENCES students(id),
    course_id TEXT NOT NULL REFERENCES courses(id),
    progress INTEGER DEFAULT 0,
    completed BOOLEAN DEFAULT FALSE,
    expire_time DATETIME,
    UNIQUE(student_id, course_id)   -- 같은 학생+강의 짝은 1개만!
);

실제 호출 흐름 (짧게)

1) 수강 등록 = 연관 리소스 생성
POST /enrollments { studentId: "alice", courseId: "python-101" }
→ 201 Created (progress: 0, completed: false 로 시작)

2) 중복 등록 시도 → 막힘
POST /enrollments { studentId: "alice", courseId: "python-101" }
→ 409 Conflict "이미 수강 중입니다"

3) 진도 업데이트 (정보만 수정)
PATCH /enrollments/enr-001 { progress: 50 }
→ progress만 50으로, studentId·courseId는 그대로

4) 수료 처리
PATCH /enrollments/enr-001 { progress: 100, completed: true }

5) 수강 취소 = 연관 리소스 삭제
DELETE /enrollments/enr-002
→ 204 No Content

이 시나리오의 한 문장

관계 자체에 대한 질문("Alice의 진도율은?", "수료했나?", "언제 만료?")이 생기면, 그 관계는 자원이 되어야 합니다.

비유부터 표로 연결

비유 API/코드 위험·주의
수강증 발급 POST /enrollments 같은 강의 수강증은 1장만
수강증에 진도 도장 찍기 PATCH (progress 수정) 누구·어떤 강의 칸은 못 고침
수강증 반납 DELETE /enrollments/{id} 학생·강의는 안 지워짐

4. 연관 리소스에 이름 붙이기

연관 리소스도 이름이 필요해요. 규칙은 단순합니다. 도메인에 자연스러운 말이 있으면 그걸 먼저 쓰고, 없으면 정해진 접미사를 붙입니다.

방식 예시 언제
도메인 용어 (가장 좋음) CourseEnrollment, Subscription 이미 적절한 말이 있을 때
이름 + Membership ChannelMembership, TeamMembership "멤버"가 자연스러울 때
이름 + Association UserProjectAssociation 중립적 관계일 때
그냥 Membership Membership 관계 종류가 하나뿐이고 맥락이 분명할 때

Membership(멤버십): "~의 일원임"이라는 뜻. 일상 비유로, 헬스장 회원증 같은 거예요. Association(어소시에이션): "엮임·연결"이라는 중립적인 말이에요.

잘못된 예 vs 올바른 예 (이름 짓기)

강사 ↔ 강의
잘못: CourseInstructorship   (어색하고 길다)
올바름: CourseTeaching 또는 InstructorAssignment

상품 ↔ 카테고리
올바름: ProductCategorization 또는 ProductCategoryMapping

사용자 ↔ 쿠폰
올바름: CouponAssignment

5. 어떤 메서드를 붙이나 — 표준 메서드

연관 리소스도 보통 자원처럼 다룹니다. 다만 꼭 필요한 것있으면 좋은 것이 나뉘어요.

메서드 하는 일 필요성
Create 두 자원 연결 (가입) 필수
Delete 연결 제거 (탈퇴) 필수
List 연결들 나열 필수
Get 관계 정보 하나 확인 정보 있을 때만
Update 관계 정보 수정 정보 있을 때만

외우기 쉽게: 만들기·지우기·나열하기는 항상, 조회·수정은 붙일 정보가 있을 때만.

채널 멤버십 CRUD 한 바퀴

1. CREATE
POST /memberships { channelId: "engineering", userId: "alice", role: "member" }
→ 201 (joinedAt은 서버가 자동 기록)

2. GET
GET /memberships/cm-101
→ 그 멤버십 하나의 정보

3. LIST (필터 가능)
GET /memberships?filter=channelId='engineering'
→ 그 채널의 멤버십 목록 (+ nextPageToken 페이징)

4. UPDATE (정보만)
PATCH /memberships/cm-101 { role: "admin" }
→ role만 바뀜

5. DELETE
DELETE /memberships/cm-101
→ 204

정보가 없으면 더 단순하게

게시글-태그처럼 붙일 정보가 없으면 Get·Update가 필요 없어요.

PostTagging { postId, tagId }   // 정보 없음

→ Create, List, Delete만 있으면 충분
POST /taggings, GET /taggings, DELETE /taggings/{id}

비유부터 표로 연결

비유 API/코드 위험·주의
회원 등록 Create (필수) 빠지면 가입 자체가 불가
회원 탈퇴 Delete (필수) 빠지면 탈퇴 불가
회원 명부 보기 List (필수) 빠지면 누가 있는지 모름
회원증 하나 들춰보기 Get (정보 있을 때) 정보 없으면 생략 가능

6. 같은 짝은 하나만 — 고유성

Alice를 #engineering에 두 번 넣으면 어떻게 될까요?

POST /memberships { userId: "alice", channelId: "engineering" }
→ 201 (처음이니 성공)

POST /memberships { userId: "alice", channelId: "engineering" }
→ 409 Conflict (이미 있음!)

두 번째는 막아야 해요. 안 막으면 이런 일이 터집니다.

- 멤버 목록에 Alice가 두 번 뜸 (혼란)
- 역할을 바꿀 때 둘 중 뭘 바꿔야 할지 모호
- 탈퇴할 때 하나만 지우면 관계가 남아있음

이게 고유성 제약이에요.

고유성 제약: 같은 짝을 잇는 연관 리소스는 딱 하나만 있어야 한다는 규칙. 일상 비유로, 같은 두 사람의 결혼 증명서가 두 장 있으면 안 되는 것과 같아요.

보통 자원과 다른 점 (중요)

보통 자원: ID가 같으면 충돌
POST /users { id: "alice" } → 201
POST /users { id: "alice" } → 409 (같은 ID)

연관 리소스: ID가 달라도 "짝"이 같으면 충돌
POST /memberships { userId: "alice", channelId: "eng" } → cm-1 생성
POST /memberships { userId: "alice", channelId: "eng" } → 409
   (cm-2로 새 ID를 받아도, alice+eng 짝이 겹치니까 거부!)

비유부터 표로 연결

비유 API/코드 위험·주의
같은 부부의 증명서는 한 장 (userId, groupId) 짝 유일 ID 달라도 짝 겹치면 409
명부에 같은 사람 한 번만 중복 가입 차단 안 막으면 목록·삭제가 꼬임

7. 양쪽 이름표는 못 바꾼다 — 읽기 전용 필드

멤버십의 userId를 바꿀 수 있게 두면 사고가 납니다.

지금: cm-101 = { userId: "alice", channelId: "engineering", role: "admin" }

PATCH /memberships/cm-101 { userId: "bob" }   // 만약 허용한다면?
→ "Alice의 admin 멤버십"이 "Bob의 admin 멤버십"으로 변신
→ Alice는 갑자기 쫓겨나고, Bob은 본인도 모르게 admin이 됨

이건 "관계를 고친" 게 아니라 완전히 다른 관계가 된 거예요. 그래서 양쪽 참조(userId, groupId)는 읽기 전용으로 잠급니다.

읽기 전용 필드: 만들 때 한 번 정해지면 그 뒤로는 못 바꾸는 필드. 일상 비유로, 결혼 증명서의 신랑·신부 이름 같은 거예요. (고치는 게 아니라 새로 발급해야 함)

대상을 정말 바꾸려면? 고치지 말고 지우고 새로 만드세요.

DELETE /memberships/cm-101                     // Alice 멤버십 파기
POST /memberships { userId: "bob", channelId: "engineering", role: "admin" }  // Bob 새로 발급

역할(role)이나 만료일(expireTime) 같은 정보는 바꿔도 됩니다. 바뀌면 안 되는 건 "누구와 누구"라는 짝뿐이에요.

읽기 전용 필드에 값을 보내면?

방식 1: 조용히 무시 (권장)
PATCH /memberships/cm-101 { userId: "bob", role: "admin" }
→ 200 { userId: "alice", role: "admin" }   // userId는 무시, role만 반영

방식 2: 에러 반환 (엄격)
→ 400 Bad Request "userId is read-only"

어느 쪽이든 결과는 같아요. userId는 절대 안 바뀝니다.

비유부터 표로 연결

비유 API/코드 위험·주의
증명서 이름은 고칠 수 없음 userId·groupId 읽기 전용 바꾸면 다른 관계가 됨
정정이 아니라 재발급 삭제 후 재생성 PATCH로 대상 바꾸기 시도 금지
결혼일은 도장 찍히는 정보 role·expireTime 수정 가능 짝만 아니면 수정 OK

8. 자주 쓰는 조회를 줄여 쓰기 — 별칭 메서드

"지미가 속한 그룹 목록"을 보려면 원래는 필터를 써야 해요.

ListMemberships({ filter: "userId='users/jimmy'" })   // 길다

그런데 이 조회는 너무 자주 쓰여서, 줄임 메서드를 따로 만듭니다.

ListUserGroups({ userId: "users/jimmy" })   // 짧고 직관적

이게 별칭 메서드예요.

별칭 메서드: 자주 쓰는 필터 조회를 짧게 줄여 만든 전용 메서드. 일상 비유로, 카페에서 "따뜻한 아메리카노 주세요" 대신 "따아요" 하는 거예요.

이름 짓는 규칙 — 단수 + 복수

ListUser Groups   → 주어진 "사용자(단수)"의 "그룹들(복수)"
ListGroup Users   → 주어진 "그룹(단수)"의 "사용자들(복수)"

HTTP 주소 패턴

GET /users/jimmy/groups   → 지미가 속한 그룹들
GET /groups/1/users       → 그룹 1에 속한 사용자들

핵심 — 별칭은 멤버십이 아니라 "상대 자원"을 돌려준다

ListGroupUsers({ groupId: "groups/1" })
→ User[] 반환 (사용자 목록!)   ← Membership 아님

ListUserGroups({ userId: "users/jimmy" })
→ Group[] 반환 (그룹 목록!)    ← Membership 아님

멤버십 자체(+ 역할·가입일)가 필요하면 그땐 ListMemberships
ListMemberships({ filter: "groupId='groups/1'" })
→ Membership[] 반환

비유부터 표로 연결

비유 API/코드 위험·주의
"따아요" 줄임 주문 ListUserGroups / ListGroupUsers 정보(role 등)는 안 옴
단수의 복수를 달라 이름 = 단수 + 복수 순서 헷갈리면 의미 반대됨
음료가 나옴 (영수증 아님) 상대 자원 반환 멤버십 원하면 ListMemberships

9. 가리키던 게 사라지면? — 참조 무결성

"축구부" 그룹을 지우려는데, 지미와 샐리의 멤버십이 이 그룹을 가리키고 있어요. 그냥 지우면 그 멤버십들은 사라진 그룹을 가리키는 유령 종이가 됩니다.

참조 무결성: 모든 참조가 실제로 존재하는 대상을 가리키는 상태. 일상 비유로, 명함에 적힌 회사가 진짜 있어야 하는 것과 같아요. (회사가 망했는데 명함만 남으면 유령 참조)

대응하는 길은 4가지예요.

방식 1: 캐스케이드 (같이 삭제)
그룹 지우면 → 관련 멤버십도 전부 자동 삭제
장점: 유령 없음
단점: 멤버 100만이면 삭제에 수 분, 연쇄 삭제 위험
판정: ⚠️ 대규모에선 비현실적

방식 2: 제한 (Restrict) ← 권장!
멤버십이 가리키는 동안엔 그룹 삭제 자체를 막음
DELETE /groups/축구부 → 409 "2개 멤버십이 참조 중. 먼저 정리하세요"
장점: 가장 안전, 실수로 인한 데이터 유실 없음
단점: 지우기 전에 정리 작업 필요
판정: ✅ 1순위

방식 3: null 설정
그룹 지우면 → 멤버십의 groupId를 null로
단점: 100만 건 업데이트 + groupId가 null인 멤버십은 의미가 없음
판정: ⚠️ 연관 리소스엔 부적합

방식 4: 방치 (그대로 둠)
그룹 지워도 멤버십은 사라진 그룹을 그대로 가리킴 (유령 종이)
장점: 단순, 즉시 삭제
단점: 클라이언트가 매번 참조가 유효한지 확인해야 함
판정: ✅ 2순위 (단순한 시스템)

실무 권장 정리

1순위: 제한(Restrict) — 가장 안전. 멤버를 먼저 정리해야 그룹 삭제 가능.
2순위: 방치(Leave) — 가장 단순. 유령 참조는 클라이언트가 처리.
비추천: 캐스케이드 / null 설정 — 대규모에서 비현실적.

비유부터 표로 연결

비유 API/코드 위험·주의
회사 망하면 명함도 회수 캐스케이드 명함 100만 장이면 회수에 한참
직원 있으면 회사 폐업 막기 제한(Restrict) ✅ 먼저 직원 정리 필요
명함의 회사 칸을 공란으로 null 설정 어느 회사였는지 사라짐
망한 회사 명함 그냥 둠 방치 ✅ 받는 쪽이 유효한지 확인해야 함

한 걸음 더 ▸ 13장(교차 참조)에서도 똑같이 "방치"를 권장했어요. 단순 참조든 연관 리소스든, 대규모에선 즉시 삭제 + 유령은 클라이언트 처리가 현실적입니다.


10. 백엔드로 직접 만들어보기 (Flask + SQLite)

말로만 보면 손에 안 잡히니, 핵심 동작 3개를 진짜 코드로 봅시다.

생성 — 고유성 검사로 409 처리

@app.route("/memberships", methods=["POST"])
def create_membership():
    data = request.json
    user_id, group_id = data["userId"], data["groupId"]
    db = sqlite3.connect("membership.db")

    # 같은 짝이 이미 있나? → 있으면 409
    existing = db.execute(
        "SELECT id FROM memberships WHERE user_id=? AND group_id=?",
        (user_id, group_id)).fetchone()
    if existing:
        return jsonify({"error": "이미 이 그룹의 멤버입니다"}), 409

    membership_id = f"memberships/{uuid.uuid4().hex[:8]}"
    db.execute("INSERT INTO memberships (id, user_id, group_id, role) VALUES (?,?,?,?)",
               (membership_id, user_id, group_id, data.get("role", "member")))
    db.commit()
    return jsonify({"id": membership_id, ...}), 201

별칭 메서드 — JOIN으로 "상대 자원"을 돌려줌

@app.route("/groups/<group_id>/users", methods=["GET"])
def list_group_users(group_id):
    db = sqlite3.connect("membership.db")
    rows = db.execute("""
        SELECT u.id, u.display_name, u.email
        FROM users u
        JOIN memberships m ON u.id = m.user_id
        WHERE m.group_id = ?
    """, (group_id,)).fetchall()
    # Membership이 아니라 User를 반환!
    return jsonify({"users": [{"id": r[0], "displayName": r[1]} for r in rows]})

그룹 삭제 — 제한(Restrict)으로 참조 무결성

@app.route("/groups/<group_id>", methods=["DELETE"])
def delete_group(group_id):
    db = sqlite3.connect("membership.db")
    count = db.execute(
        "SELECT COUNT(*) FROM memberships WHERE group_id=?",
        (group_id,)).fetchone()[0]
    if count > 0:                      # 참조 중이면 삭제 거부
        return jsonify({"error": f"{count}개 멤버십이 참조 중"}), 409
    db.execute("DELETE FROM groups_ WHERE id=?", (group_id,))
    db.commit()
    return "", 204

빈칸 채우기 연습 (스스로 해보기)

# Q: 중복 멤버십을 막는 SQL의 빈칸은?
existing = db.execute(
    "SELECT id FROM memberships WHERE user_id=? AND ______=?",
    (user_id, group_id)).fetchone()
# 답: group_id  (짝의 양쪽을 모두 검사해야 하니까)

독립 적용 (혼자 설계해보기)

ListUserGroups("/users/<user_id>/groups")를 위 ListGroupUsers를 본떠 직접 작성해보세요.
힌트: groups_ 와 memberships를 JOIN, WHERE는 m.user_id = ?, 반환은 Group[].

10.5. 전체 API 한눈에 보기 — 메서드가 17개가 됐어요

여기까지 부분부분 만들어 봤죠. 그런데 막상 다 모아보면 "어? 이렇게 많았나?" 싶어요. 끝내기 전에 지금까지 붙인 메서드를 전부 한 장에 모아 세어봅시다.

이게 중요한 이유부터. 1절에서 "User에 groupIds 배열 하나만 두면 끝"처럼 보였던 게 기억나죠? 연관 리소스로 제대로 만들면 그 단순함이 사라지고, 자원 3개에 메서드 17개짜리 인터페이스가 됩니다. "관계 하나당 7개"라는 11절의 수치는 멤버십 한 줄만 센 거예요. 실제로는 부모 자원(User·Group)까지 합쳐 전체 표면이 얼마나 커지는지 봐야 비용이 손에 잡혀요.

일상 비유 먼저

동아리 관리 사무실을 차린다고 해봐요.

  • 학생 서류함: 학생을 등록·조회·목록·수정·폐기 → 처리 창구 5개
  • 동아리 서류함: 동아리를 등록·조회·목록·수정·폐기 → 처리 창구 5개
  • 가입 증명서함: 멤버십을 발급·조회·목록·수정·파기 → 처리 창구 5개
  • 빠른 조회 창구 2개: "이 학생의 동아리들", "이 동아리의 학생들"

창구를 다 세면 5 + 5 + 5 + 2 = 17개. 배열 하나로 끝날 줄 알았는데, 제대로 차린 사무실은 창구가 17개나 필요해요.

비유부터 표로 연결

비유 API/코드 위험·주의
학생 서류함 처리 창구 5개 User 표준 메서드 (Create/Get/List/Update/Delete) 부모 자원도 메서드를 다 가짐
가입 증명서함 처리 창구 5개 Membership 표준 메서드 5개 정보 없으면 Get·Update는 줄어듦
빠른 조회 창구 2개 별칭 메서드 (ListUserGroups·ListGroupUsers) 멤버십이 아니라 상대 자원을 돌려줌
창구 17개짜리 사무실 GroupApi 전체 인터페이스 단순 배열보다 훨씬 커짐 = 비용

누적 카운트 — 한 줄씩 더해보기

User 메서드:       5개 (Create, Get, List, Update, Delete)
Group 메서드:      5개 (Create, Get, List, Update, Delete)   → 누적 10
Membership 메서드: 5개 (Create, Get, List, Update, Delete)   → 누적 15
별칭 메서드:       2개 (ListGroupUsers, ListUserGroups)       → 누적 17
─────────────────────────────────────────────
합계: 17개 메서드

11절의 "관계당 7개(표준 5 + 별칭 2)"는 이 17개 중 멤버십 줄(5) + 별칭(2) 만 떼어 센 숫자예요. 부모 자원 둘(User 5 + Group 5 = 10)을 합치면 전체 API 표면은 17개로 불어납니다.

통합 인터페이스 — 부모 자원 + 연관 자원을 한 클래스에

이 17개를 한 곳에 모은 게 최종 API 정의예요. @post·@get 같은 데코레이터는 "이 메서드는 어떤 HTTP 주소·동작에 연결된다"는 꼬리표라고 보면 돼요.

class GroupApi:
    # User 표준 5개
    @post("/users")            def CreateUser(req): ...
    @get("/users/{id}")        def GetUser(req): ...
    @get("/users")             def ListUsers(req): ...
    @patch("/users/{id}")      def UpdateUser(req): ...
    @delete("/users/{id}")     def DeleteUser(req): ...

    # Group 표준 5개
    @post("/groups")           def CreateGroup(req): ...
    @get("/groups/{id}")       def GetGroup(req): ...
    @get("/groups")            def ListGroups(req): ...
    @patch("/groups/{id}")     def UpdateGroup(req): ...
    @delete("/groups/{id}")    def DeleteGroup(req): ...

    # Membership 표준 5개 (연관 자원)
    @post("/memberships")      def CreateMembership(req): ...
    @get("/memberships/{id}")  def GetMembership(req): ...
    @get("/memberships")       def ListMemberships(req): ...
    @patch("/memberships/{id}") def UpdateMembership(req): ...
    @delete("/memberships/{id}") def DeleteMembership(req): ...

    # 별칭 2개 — 부모 주소 밑에 연관 자원을 엮음
    @get("/groups/{groupId}/users")  def ListGroupUsers(req): ...   # 이 그룹의 학생들
    @get("/users/{userId}/groups")   def ListUserGroups(req): ...   # 이 학생의 그룹들

핵심은 별칭 두 줄이에요. @get("/groups/{groupId}/users")는 부모 자원(Group) 주소 밑에 연관 자원(멤버십이 잇는 User들)을 끼워 넣은 거예요. 그래서 통합 인터페이스 하나로 "부모 자원"과 "그 부모에 엮인 상대"를 같이 노출합니다.

잘못된 예 vs 올바른 예 (셈하기)

잘못된 셈 — 멤버십만 세고 끝내기

"연관 리소스 비용? 7개네."   ← 멤버십 5 + 별칭 2만 셈
→ 부모 자원 10개를 빠뜨려서 전체 표면을 과소평가

올바른 셈 — 부모까지 누적

User 5 + Group 5 + Membership 5 + 별칭 2 = 17개
→ "배열 하나"로 보였던 게 실제론 17개 창구임을 정직하게 인식

한 걸음 더 ▸ 만약 정보가 전혀 없어서 멤버십에 Get·Update가 빠지면, 멤버십은 3개(Create·List·Delete)로 줄어 전체는 15개가 돼요. 정보 유무가 표면 크기를 직접 바꾼다는 걸 숫자로 확인할 수 있죠.


11. 언제 연관 리소스를 쓰나 — 트레이드오프

연관 리소스는 강력하지만 공짜가 아니에요.

비용 1 — API가 커진다

관계 하나당 최대 7개 메서드(표준 5 + 별칭 2)가 늘어요.

넣기/빼기 방식(다음 장): POST /groups/1/users:add  (메서드 1~4개, 정보 불가)
연관 리소스 방식: Create/Get/List/Update/Delete + 별칭 2개 = 최대 7개

정보가 필요 없으면 더 단순한 방식이 나을 수 있어요.

비용 2 — 정보가 따로 논다

그룹 기본 정보와 멤버 목록을 한 번에 못 가져와요.

GET /groups/1          → { name, description }   (멤버는 없음)
GET /groups/1/users    → [멤버 목록]             (따로 호출)
→ 클라이언트가 2번 호출

그래서 언제? — 판단 한 줄

관계에 붙일 정보가 있나?
├─ 있다 → 연관 리소스 (14장)
└─ 없다 → 관계의 있다/없다만 필요
          ├─ 단순함이 우선 → 넣기/빼기 (다음 장)
          └─ CRUD 일관성 우선 → 연관 리소스 (정보 없이도)

실전 판단 5문제 (정보 유무로 가른다)

1) GitHub Star (사용자 ↔ 저장소)
   정보: 없음 (찍었다/안 찍었다 뿐)
   → 정보 없음. 넣기/빼기가 적합. 연관 리소스는 과잉.

2) Slack 채널 멤버 (사용자 ↔ 채널)
   정보: 역할, 가입일, 알림설정, 마지막 읽은 위치 (4개)
   → 정보 많음. 연관 리소스(ChannelMembership).

3) 블로그 태그 (글 ↔ 태그)
   정보: 없음 (붙었다/안 붙었다)
   → 넣기/빼기. 단, "태그 순서"가 필요하면 순서가 정보이므로 연관 리소스.

4) 수강 등록 (학생 ↔ 강의)
   정보: 진도율, 수료여부, 만료일
   → 연관 리소스(CourseEnrollment). 진도율은 핵심 데이터.

5) 팀 배정 (직원 ↔ 프로젝트)
   정보: 역할(PM/개발/디자인), 참여율, 시작·종료일
   → 연관 리소스(ProjectAssignment).

잘못 고르면 생기는 일

올바른 선택 잘못 골랐을 때 생기는 문제
연관 리소스가 정답인데 넣기/빼기 사용 정보 저장 불가 핵심 데이터 누락, 나중에 갈아엎기(breaking change)
넣기/빼기가 정답인데 연관 리소스 사용 메서드 7개 과잉 API 복잡, 클라이언트 부담
단순 참조(13장)가 정답인데 연관 리소스 불필요한 중간 자원 간단한 걸 복잡하게

한 걸음 더 ▸ 나중에 정보가 붙을 것 같으면 처음부터 연관 리소스로 가는 게 안전해요. 넣기/빼기로 시작했다가 연관 리소스로 바꾸면 기존 클라이언트가 깨지는 큰 변경이 됩니다.


마무리 — 이 장 한 장 요약

다대다 + 관계 정보 → 관계를 자원으로 (연관 리소스)
만들기·지우기·나열하기는 필수, 조회·수정은 정보 있을 때만
같은 짝은 하나만 (고유성), 양쪽 이름표는 못 바꿈 (읽기 전용)
자주 쓰는 조회는 별칭 메서드로 줄여 쓰되 상대 자원을 돌려줌
대상이 사라질 때는 제한(권장) 또는 방치로 처리
정보가 없으면 더 단순한 방식이 나을 수 있다

다음 장 예고: 관계에 붙일 정보가 전혀 없을 때 쓰는 더 단순한 "넣기/빼기(add/remove)" 방식을 다룹니다. 지금 몰라도 됩니다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플